fix(objectql): buildSummaryIndex reports the skip when a roll-up's reference carrier is unreadable - #19293
Conversation
…ence` carrier is unreadable The child->parent FK was resolved by comparing the child field's `reference` carrier to the parent's name. A carrier no reader can read -- a non-string, where `FieldSchema.reference` declares an optional string -- compared false against every name, `fkField` stayed unset, and the `continue` dropped a DECLARED `summary` field out of both indexes with no diagnostic anywhere. The parent's stored summary value then kept whatever it held through every insert / update / delete of the child, while each write reported success. The resolution RULE is deliberately unchanged (a looser comparison would trade a silent stall for a mis-matched foreign key, which is more expensive). The carrier is read through the one arbiter, `referenceCarrierOf`, and the skip now reports itself at `error` with both the consequence and the fix. Absence (undefined/null/'') stays silent, every readable carrier resolves exactly as before, and the arbiter's refusal is caught rather than propagated so an unreadable sibling cannot hide the readable field that IS the foreign key. Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
…dex skip Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3e302c49392e00f3b48e990329442615d9f94403 && git checkout 3e302c49392e00f3b48e990329442615d9f94403
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin adf4b18777d507236cd24b7ed59b45a7c71bd1fd 308a340371a05fa7f263acdacfa46df7934e6426 && git checkout -B drift-repro adf4b18777d507236cd24b7ed59b45a7c71bd1fd && git merge --no-ff 308a340371a05fa7f263acdacfa46df7934e6426
node scripts/docs-audit/affected-docs.mjs --json adf4b18777d507236cd24b7ed59b45a7c71bd1fd
|
…ur repaired through `referenceTargetOf` (objectstack-ai#19289) (objectstack-ai#19472) Fixes objectstack-ai#19289 Clause-②: no `IMPLICIT_REFERENCE_TARGETS` (`packages/spec/src/data/field-value.zod.ts`) declares a `user` field's target "a **CONSTANT OF THE TYPE**" and metadata authored without `reference` "**fully specified, not under-specified**". Two arbiters answer two different questions — `referenceCarrierOf` what the carrier says, `referenceTargetOf` what the field points at — and for `user` only the second matches that text. objectstack-ai#18550 standardised a population of readers on the first. This is the census of that population. **The arbiters are NOT edited.** `packages/spec/src/data/field-value.zod.ts` is untouched; the diff is `packages/lint`, `packages/metadata-protocol`, `packages/rest` and their tests. ## The census was re-measured, and it had moved Taken on `8f6d831` with **no pathspec**, excluding tests and the arbiters' own module: **17 `referenceCarrierOf` vs 8 `referenceTargetOf`** — ⛔ not the card's 16 vs 7. Class (A) is **8** (card: 7); class (B) is **9** (unchanged). Both deltas landed *after* the card's census: | delta | commit | landed | |---|---|---| | `objectql/src/engine.ts:9077` — a NEW class-(A) carrier site (`buildSummaryIndex`) | `875e9ad` (objectstack-ai#19293) | 2026-09-20T10:37Z | | `plugin-audit/src/audit-writers.ts:429` — raw read → target, so the target count rose | `5636641` (objectstack-ai#19264 / PR objectstack-ai#19285) | 2026-09-20T10:09Z | The `objectql/engine.ts` line numbers also drifted (13113 → 13204, 13568 → 13659). The card's instrument trap reproduces on this tree, with `REFERENCE_FIELD_TYPES` as the known-present needle: bare `packages/**/src/**/*.ts` → **0 files**, `:(glob)…` → **2**, no pathspec → **2**. ## Per-site verdict — all 17 The deciding question is each site's **own type gate**: where it excludes `user`, the carrier *is* the target for the question that site asks, and the site stays on the carrier. ### Class (A) — handed a real field definition (8) | # | site | own type gate | verdict | |---|---|---|---| | A1 | `metadata-protocol/src/seed-loader.ts:712` | `lookup \| master_detail \| user` — **admits** | 🔴 **DEFECT ① (silent)** — repaired | | A2 | `rest/src/rest-server.ts:10914` | **none** — any field the picker names | 🔴 **DEFECT ② (loud, 500)** — repaired | | A3 | `cli/src/commands/doctor.ts:726` | `lookup` only | ✅ not a defect — type-gate exclusion | | A4 | `cli/src/commands/doctor.ts:930` | `lookup` only | ✅ not a defect — type-gate exclusion | | A5 | `objectql/src/engine.ts:9077` *(new)* | `master_detail \| lookup` | ✅ not a defect — type-gate exclusion | | A6 | `objectql/src/engine.ts:13204` | `master_detail \| lookup` | ✅ not a defect — type-gate exclusion | | A7 | `objectql/src/engine.ts:13659` | `master_detail \| lookup` | ✅ not a defect — type-gate exclusion | | A8 | `services/service-analytics/src/plugin.ts:748` | `lookup \| master_detail` | ✅ not a defect — pre-judged on the card | ### Class (B) — synthesize `{ reference: … }`, discarding `type` (9) | # | site | own type gate | verdict | |---|---|---|---| | B1 | `lint/src/data-model-rules.ts:250` (`refOf`) | 4 callers: `RELATIONSHIP_TYPES` = `{lookup, master_detail}`, `OPTION_FIELD_TYPES`, `summary` | ✅ not a defect | | B2 | `lint/src/object-graph.ts:242` (`graphFieldOf`) | **none**; consumer `RELATIONSHIP_FIELD_TYPES` **admits `user`** | 🔴 **DEFECT ③ (silent, widest)** — repaired | | B3 | `lint/src/validate-expressions.ts:395` | `master_detail` only | ✅ not a defect | | B4 | `lint/src/validate-field-consumers.ts:560` | **none** | 🔴 **DEFECT ④ (silent)** — repaired | | B5 | `lint/src/validate-object-references.ts:306` | `RELATIONSHIP_TARGET_FIELD_TYPES` **admits `user`** | 🟡 latent misread — aligned, no output change | | B6 | `lint/src/validate-object-references.ts:329` (action param) | `ActionParamSchema.type` is **optional** | ✅ not a defect — **measured**, see below | | B7 | `lint/src/validate-security-posture.ts:292` (`refOf`) | `CBP_TIERS` = master_detail / lookup | ✅ not a defect | | B8 | `lint/src/validate-sharing-rule-enforceability.ts:267` | `master_detail` only | ✅ not a defect | | B9 | `verify/src/derive.ts:148` | `RELATIONAL` = lookup / master_detail / tree — **excludes `user`** | ✅ not a defect | ⛔ **Class (B) was smaller than the card's framing, not larger.** Seven of the nine are type-gated away from `user`; only B2 and B4 needed the pass-the-field-through repair. Three of the nine (B3, B7, B9) additionally keep their synthesized `{ reference: x.reference }` literal because the **objectstack-ai#5017 receiver meta-test reads their source** to prove they read `reference` and never an alias — folding that read into a helper call would disarm that scan silently. Those three are untouched. ## B6 — the site the tests refused, and why it is in the table as judged I initially swapped the action-param site too. `reference-integrity-suite.test.ts` went red: `object-reference-unknown` vanished from the suite's findings entirely. The cause is that a param is **not** a field definition — `ActionParamSchema.type` is optional, because a field-backed param inherits its type at runtime, "not visible at parse time" per that schema's own refinement comment. So `referenceTargetOf` answered `undefined` for **every param that declares no type**, and the corpus param `{ name: 'owner', reference: 'user' }` (`user` being the classic miss for `sys_user`) stopped being checked. The swap deleted a live check. Nothing was owed there in the other direction either: a carrier-less `user` param already produced no finding, because `check` returns early on absence. Reverted, verdict recorded as not-a-defect, with a regression guard kept beside it. ## The four repairs 1. **`metadata-protocol` seed-loader — SILENT, and it stored a wrong value.** A `{type:'user'}` field with no `reference` contributed no `dependsOn` edge and never reached `references`, so its natural key was written **verbatim** into a column holding a record id. 2. **`rest` public-form picker — LOUD.** A `publicPicker` on a spec-complete `{type:'user'}` field answered `500 LOOKUP_TARGET_MISSING`. Now `200` over `sys_user`. 3. **`lint` `object-graph.graphFieldOf` — SILENT and widest.** The slice feeds `resolveFieldPath`, whose `RELATIONSHIP_FIELD_TYPES` admits `user`; a carrier-less one answered `hop-untargeted`, which `isUnjudgeable` treats as "the graph could not answer" and `describeFieldPathVerdict` renders as *nothing*. Every rule in the package that resolves a field path therefore **stopped judging** any path through such a field — the failure mode `isUnjudgeable`'s own docblock says this family exists to end ("a missed report is silence"). 4. **`lint` `validate-field-consumers.walkObject` — SILENT.** The `displayField` consumer edge onto `sys_user` was never recorded, so a field that column displays was reported consumed by nobody.⚠️ Materiality stated honestly: recordable only where `sys_user` is compiled into the linted stack. **Nothing widens.** `user` is the only member of `IMPLICIT_REFERENCE_TARGETS`; `lookup` / `master_detail` / `tree` with an absent carrier still name nothing, pinned at every repaired site. **The unreadable-carrier refusal is unchanged** — `referenceTargetOf` reads the carrier through `referenceCarrierOf` *before* it judges the type, so objectstack-ai#13053/objectstack-ai#18550's `TypeError` still fires everywhere it fired. ⛔ **Not a re-widening of objectstack-ai#12920.** A control pins it: a `user` field spelling `referenceTo: 'zzz_aliased_object'` resolves `sys_user` from the type and is never asked for the aliased name; a `lookup` spelling the same alias still resolves nothing and still answers `500`. ## Evidence - `@objectstack/lint` — **106 files / 4019 tests passed** - `@objectstack/metadata-protocol` — **184 passed, 3 skipped / 2627 passed, 19 skipped** - `@objectstack/rest` — **194 files / 3254 passed, 1 skipped** - `typecheck` green on all three (incl. `check:test-typecheck` for lint and rest) - Re-run in full **after** merging `origin/main`; the closure was rebuilt first because `packages/spec` moved on main's side - **Gates:** `dispatch-gates --ran` reconciles **63 derived / 60 run / 3 NOT MEASURED / 0 UNRUN**. The three are `PREREQUISITE NOT MET` (exit 3, ⛔ not a pass): `check:dual-build-cjs-loads` and `check:type-check-debt` need a whole-repo build; `check-plugin-teardown-shape --self-test` cannot reach a commit-pinned fixture on a shallow clone. - **`pnpm lint`, narrowed and declared:** ① the population is read from `eslint.config.mjs`, which states in its own comment that this repo "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file" ⇒ this diff cannot move the verdict on any file it does not touch; ② `--format json` reports **37 files linted, 0 errors, 0 warnings** (a superset — the three-dot set includes what the merge brought); ③ measured at `97b689b`. - Control-character self-scan over the 12 changed files: 0 hits. ## Attribution Authored by Claude Code session `session_01UDXER3sdqfeVYpEWZs5mZx`. Recorded here in prose deliberately: this body was edited on the raw REST edit side, which appends the BARE footer form carrying no session id (pm-dispatch `references/platform-readings.md`:350). ## Acceptance notes - **Noted, not filed:** `referenceTargetOf` takes no `reader` label, while `referenceCarrierOf` does precisely so "the message says who could not read it". Every site moved onto the target arbiter therefore loses its own name from the refusal, and four existing pins were retargeted from the site label to `/referenceTargetOf/` here. The error class, the offending shape and the prescription stay asserted, and this matches the two landed repairs (objectstack-ai#19198, objectstack-ai#19264). It is a diagnostic-fidelity question about the arbiter's signature, ⛔ not a defect — fixing it would edit `packages/spec/src/data/field-value.zod.ts` and change this card's landing path. **Successor: the next PR that moves a consumer onto `referenceTargetOf`.** - **Noted, not filed:** `graphFieldOf` no longer populates `reference` for a **non-relationship** field carrying a stray `reference` (e.g. `{type:'text', reference:'foo'}`), because `referenceTargetOf` returns `undefined` outside `REFERENCE_VALUE_TYPES`.⚠️ **Corrected by the at-tier contract review (`5754774179`), re-measured on this head by the seat:** `resolveFieldPath`'s `RELATIONSHIP_FIELD_TYPES` gate is on the intermediate HOPS (`object-graph.ts:405`, `hop-untargeted`), ⛔ not on the leaf — the leaf comes back `ok` carrying its `meta` whatever its type (`object-graph.ts:413`) — and `validate-preset-comparands.ts:450` DOES read it (`verdict?.kind === 'ok' ? strName(verdict.meta?.reference) : undefined`). So «no other module reads `GraphField.reference`» is **false on the tree**, and the sentence is withdrawn. What actually follows: a `user` picker's filter rows now bind to `sys_user`, which is the object the route queries — correct; a `text`-plus-stray-carrier picker's filter rows bind to nothing where they bound to the stray object. The same shape reaches the REST picker, which has no field-type gate before `referenceTargetOf` (`rest-server.ts:10935`) and whose `FieldSchema.reference` carries no non-reference-type refusal, so `{type:'text', reference:'foo'}` behind a `publicPicker` now answers 500 `LOOKUP_TARGET_MISSING` where it used to search `foo`. Both are pull-backs to the declared contract — the `reference` describe text scopes it to lookup/master_detail fields, and `forms.mdx:238` scopes pickers to lookup / master-detail / `user` — on a shape outside the documented surface, so ⛔ neither is a widening and `Clause-②: no` is unaffected. Strictly more correct: a `text` field's stray carrier is not a target. ## 维护者速读(草稿) **改了什么** — `user` 字段的目标由类型常量决定(`sys_user`),不必作者手写 `reference`。本 PR 普查了全部 17 个读「目标」的调用点,逐个判定该问哪个仲裁器,修了其中 4 个真缺陷,其余 13 个判为「读载体本来就对」并留档。 **为什么改** — 契约白纸黑字写着这种元数据「已完整声明」,但四处消费者把它当作「没写目标」。后果:公开表单上点开「负责人」选择器直接 500 错误页;种子数据把人名原样写进本该存记录 id 的列;lint 静默放弃对这类字段路径的全部校验。 **风险与代价(含回滚)** — 风险低。没有放宽任何拼写:别名仍旧拒收,不可读的载体仍旧抛错,`lookup`/`master_detail` 缺目标仍旧当作没目标。回滚即 revert 本 PR,无数据迁移、无存量数据改写。 **席位意见** — *(留空,待席位定稿)* **你要做的** — 确认一件事即可:**本 PR 没有改动 `packages/spec` 的两个仲裁器**,所以不触发合流闸的路径腿。其余按常规复核。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19082
Clause-②: no
A diagnostic added to an internal, private index neither loosens an accept set nor widens a published surface. No schema changed;
buildSummaryIndexisprivateand nothing about its signature, its return shape or its resolution rule moved.The premise, re-taken by symbol
Triage said it had not re-taken the reading and asked the executor to.
packages/objectql/src/engine.tstook a lander after the card's reading ref221dabb72—a675ad4e(#19080, the ten-residual-readers round) — so the site was re-located by symbol, never by the card's line numbers.It still resolves by carrier equality.
buildSummaryIndexis atengine.ts:9009; the comparison the card quotes at:9033is byte-identical and now sits at the same line, and the silentcontinueat:9039is unchanged. #19080 routedplanCascadeAtomicityandcascadeDeleteRelationsthrough the arbiter and left this third site alone.premise_still_valid: true.The defect
The child-to-parent foreign key is resolved by scanning the child object's
master_detail/lookupfields for one whosereferencenames the parent. That comparison read the carrier raw, so a carrier no reader can read — a non-string, whereFieldSchema.referencedeclares an optional string — comparedfalseagainst every name,fkFieldstayed unset, anddropped a declared
summaryfield out of both indexes.recomputeSummaries()then had nothing to do after every insert / update / delete of the child, so the parent's stored summary value kept whatever it held while each of those writes reported success, and nothing anywhere said so. It is the second way this one function invents "nothing to recompute"; the first, its registry read, was closed as #9154.The boundary this card asked to reopen — and where it now stands
PR #18503 recorded this site in its C2 list and the #18550 round left it there deliberately. That boundary stands: the resolution rule is untouched. Loosening the comparison would trade a silent stall for a mis-matched foreign key, which is more expensive — a roll-up quietly aggregating the wrong children reads exactly like a correct one, while a roll-up that stopped moving is at least visible to anyone who looks at the value. What ends here is only the silence, which triage named as the half available today:
referenceCarrierOf— the accessor Refuse an unreadablereferencecarrier at the ten residual readers (ruling E item 2 residue) #19080 routed the two cascade seams through;error, once per index build. A persisted summary that silently stops tracking its children while every write keeps reporting success is the durability class by AGENTS.md's own question, and the line carries both halves it owes: the consequence (which field will not recompute, and that the system keeps looking healthy) and the fix (spell the carrier as the target object's name, or name the FK withsummaryOperations.relationshipField);undefined,nulland''mean "this field names no target", which is legal; they skip silently exactly as before. Every readable carrier resolves exactly as before.The decision and its reasoning are recorded on the card and in the function's own docblock, so the next reader of the skip branch finds them instead of re-filing.
Reachability — measured, and deliberately not inflated
The card recorded this as not established, and it is now measured on this tree rather than argued. One probe, three doors, each with a readable-carrier control that passes:
{ object: 'bad' }on amaster_detailreference: 'bad'ObjectSchema.safeParse(the contract door)fields.bad.reference: invalid_typegetMetadataTypeSchema('object')— whatsaveMetaItemresolves for a stored/metawriteregistry.registerObject— the choke point every metadata door funnels through{"object":"bad"};referenceCarrierOfon the registered field throwsSo: not a live outage — the live authoring and stored-write doors refuse this shape today — and not unreachable either. The registry takes it raw, which is the population
engine.ts's own #9689 note already names for the sibling seam: "a rawregisterObject, or a stored/artifact row written before the tightening — the two populations parse-time rejection measurably cannot catch, since the engine registers raw objects and never re-parses". Graded exactly there, and ⛔ not escalated: no storedsummaryfield was measured to have never recomputed, which is this card's only escalation condition.One honest qualifier, measured in the same probe: registration does already emit an ADR-0078 completeness warning for this field (
field/relationship-without-referencefires ontypeof def.reference !== 'string'). That is a one-shot, console-carried note about the child field at registration; it does not name the parent's declaredsummaryfield, does not say the roll-up was dropped, and this package's own vitest config quiets[Registry]output towarn. It is a neighbouring signal, not this one.Tests
packages/objectql/src/engine-summary-index-unreadable-carrier.test.ts, 7 cases, both directions — because without the second, a change that simply stopped resolving anything would be indistinguishable from a fix:referencestill resolvesfkField(inv_line/inv), and the recorder stays at zero;errorand notwarn, naming the field, the consequence and both fixes;The zeros in §1, §4 and §5 are readings rather than a dead instrument: §2 drives the same recorder through the same handle and measures it at 1.
Every command below captured its exit code before any pipe, at HEAD
308a3403:pnpm --filter @objectstack/objectql testpnpm --filter @objectstack/objectql typecheckcheck:test-typecheckholds at 40 files / 234 errors / 65 signatures, unmovedpnpm --filter '@objectstack/objectql^...' buildpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsthen--ranpnpm lint(eslint . --no-inline-config, whole repo — no narrowing to declare)Five of the 62 first returned exit 2 or 3 — never a pass, nothing measured — and each was cleared rather than reported as one:
check-engine-split-ratioandcheck-plugin-teardown-shape --self-testrefused on a shallow clone (deepened withgit fetch --shallow-since=2026-06-15, both then exit 0), andcheck:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debtrefused for want of built output (built, then exit 0).Acceptance notes
Out-of-scope observations, noted and deliberately not filed:
!fkFieldskip is still silent in its other branch: a roll-up whose child declares no relation field at all is dropped with no diagnostic here. It is not this card's input, it is loud at a different layer (the ADR-0078 completeness rule fires on exactly that shape at registration, atseverity: 'error'), and widening the new diagnostic to cover it would make every legitimately-unresolvablesummarydeclaration log per index build. Successor: whoever next reopens PR Retire check-reference-carrier-shape; refuse an unreadablereferencecarrier at the reader #18503's C2 boundary for this function — the decision is now recorded inbuildSummaryIndex's docblock, where they will meet it.Sibling card #19081 shares the same root (an unreadable
referencecarrier) and is deliberately not folded in: different file, different failure direction (it leaks the carrier onward; this one silently drops work), different lane. Triage ruled both should be taken, in either order.Generated by Claude Code